var _ = require('underscore');
var path = require('path');
var fs = require('fs');
var assert = require('assert');
var Fiber = require('fibers');
var Future = require('fibers/future');
var files = require('../../files.js');
var bundler = require('../../bundler.js');
var release = require('../../release.js');
var project = require('../../project.js');
var catalog = require('../../catalog.js');
var buildmessage = require('../../buildmessage.js');
var meteorNpm = require('../../meteor-npm.js');

var lastTmpDir = null;
var tmpDir = function () {
  return (lastTmpDir = files.mkdtemp());
};

var setAppDir = function (appDir) {
  project.project.setRootDir(appDir);

  var checkoutPackageDir = path.join(
    files.getCurrentToolsDir(), 'packages');
  var localPackageDirs = [tmpPackageDirContainer, checkoutPackageDir];

  if (files.usesWarehouse()) {
    throw Error("This old test doesn't support non-checkout");
  }

  doOrThrow(function () {
    catalog.uniload.initialize({
      localPackageDirs: [checkoutPackageDir]
    });
    catalog.complete.initialize({
      localPackageDirs: localPackageDirs
    });
  });
};

var doOrThrow = function (f) {
  var ret;
  var messages = buildmessage.capture(function () {
    ret = f();
  });
  if (messages.hasMessages()) {
    throw Error(messages.formatMessages());
  }
  return ret;
};

///
/// TEST PACKAGE DIR
///
var tmpPackageDirContainer = tmpDir();
var testPackageDir = path.join(tmpPackageDirContainer, 'test-package');

var reloadPackages = function () {
  doOrThrow(function () {
    catalog.complete.refresh();
  });
};

var updateTestPackage = function (npmDependencies) {
  if (!fs.existsSync(testPackageDir))
    fs.mkdirSync(testPackageDir);

  fs.writeFileSync(path.join(testPackageDir, 'package.js'),
                   "Package.describe({version: '1.0.0'});\n"
                   + "\n"
                   + "Npm.depends(" + JSON.stringify(npmDependencies) + ");"
                   + "\n"
                   + "Package.on_use(function (api) { api.add_files('dummy.js', 'server'); });");
  // we need at least one server file, otherwise we don't bother copying
  // the gcd module into the bundle.
  fs.writeFileSync(path.join(testPackageDir, 'dummy.js'), "");
  reloadPackages();
};

///
/// TEST APP USING TEST PACKAGE DIR
///
var appWithPackageDir = path.join(__dirname, 'app-with-package');

///
/// HELPERS
///

var _assertCorrectPackageNpmDir = function (deps) {
  // test-package/.npm was generated

  // sort of a weird way to do it, but i don't want to have to look up
  // all subdependencies to write these tests, so just transplant that
  // information
  var actualMeteorNpmShrinkwrapDependencies = JSON.parse(fs.readFileSync(path.join(testPackageDir, ".npm", "package", "npm-shrinkwrap.json"), 'utf8')).dependencies;
  var expectedMeteorNpmShrinkwrapDependencies = _.object(_.map(deps, function (version, name) {
    var expected = {};
    if (/tarball/.test(version)) {
      expected.from = version;
    } else {
      expected.version = version;
    }

    // copy fields with values generated by shrinkwrap that can't be
    // known to the test author. We set keys on val always in this
    // order so that comparison works well.
    var val = {};
    _.each(['version', 'dependencies'], function (key) {
      if (expected[key])
        val[key] = expected[key];
      else if (actualMeteorNpmShrinkwrapDependencies[name] && actualMeteorNpmShrinkwrapDependencies[name][key])
        val[key] = actualMeteorNpmShrinkwrapDependencies[name][key];
    });

    return [name, val];
  }));

  var actual = fs.readFileSync(path.join(testPackageDir, ".npm", "package", "npm-shrinkwrap.json"), 'utf8');
  var expected = JSON.stringify({
    dependencies: expectedMeteorNpmShrinkwrapDependencies}, null, /*indentation, the way npm does it*/2) + '\n';

  assert.equal(actual, expected, actual + " == " + expected);

  assert.equal(
    fs.readFileSync(path.join(testPackageDir, ".npm", "package", ".gitignore"), 'utf8'),
    "node_modules\n");
  assert(fs.existsSync(path.join(testPackageDir, ".npm", "package", "README")));

  // verify the contents of the `node_modules` dir
  var nodeModulesDir = path.join(testPackageDir, ".npm", "package", "node_modules");

  // all expected dependencies are installed correctly, with the correct version
  _.each(deps, function (version, name) {
    assert(looksInstalled(nodeModulesDir, name));

    if (!/tarball/.test(version)) { // 'version' in package.json from a tarball won't be correct
      assert.equal(JSON.parse(
        fs.readFileSync(
          path.join(nodeModulesDir, name, "package.json"),
          'utf8')).version,
                   version);
    }
  });

  // all installed dependencies were expected to be found there,
  // meaning we correctly removed unused node_modules directories
  _.each(
    fs.readdirSync(nodeModulesDir),
    function (installedNodeModule) {
      if (fs.existsSync(path.join(nodeModulesDir, installedNodeModule, "package.json")))
        assert(installedNodeModule in deps);
    });
};

var _assertCorrectBundleNpmContents = function (bundleDir, deps) {
  // sanity check -- main.js has expected contents.
  assert.strictEqual(fs.readFileSync(path.join(bundleDir, "main.js"), "utf8"),
                     bundler._mainJsContents);

  var bundledPackageNodeModulesDir = path.join(
    bundleDir, 'programs', 'server', 'npm', 'test-package', 'node_modules');

  // bundle actually has the npm modules
  _.each(deps, function (version, name) {
    assert(looksInstalled(bundledPackageNodeModulesDir, name));

    if (!/tarball/.test(version)) { // 'version' in package.json from a tarball won't be correct
      assert.equal(JSON.parse(
        fs.readFileSync(path.join(bundledPackageNodeModulesDir, name, 'package.json'), 'utf8'))
                   .version,
                   version);
    }
  });
};

var looksInstalled = function (nodeModulesDir, name) {
  // All of the packages in this test have one of these two files, so presumably
  // if one of these files is here we have correctly installed the package.
  return fs.existsSync(path.join(nodeModulesDir, name, 'README.md')) ||
    fs.existsSync(path.join(nodeModulesDir, name, 'LICENSE'));
};

///
/// TESTS
///

var runTest = function () {
   // As preparation, we need to initialize the official catalog, which serves
   // as our sql data store.
   catalog.official.initialize();

  // XXX this is a huge nasty hack. see release.js,
  // #HandlePackageDirsDifferently
  console.log("app that uses gcd - clean run");
  assert.doesNotThrow(function () {
    updateTestPackage({gcd: '0.0.0'});
    var tmpOutputDir = tmpDir();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);
    _assertCorrectPackageNpmDir({gcd: '0.0.0'});
    _assertCorrectBundleNpmContents(tmpOutputDir, {gcd: '0.0.0'});
  });

  console.log("app that uses gcd - no changes, running again");
  assert.doesNotThrow(function () {
    var tmpOutputDir = tmpDir();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);
    _assertCorrectPackageNpmDir({gcd: '0.0.0'});
    _assertCorrectBundleNpmContents(tmpOutputDir, {gcd: '0.0.0'});
  });

  console.log("app that uses gcd - as would be in a 3rd party repository (no .npm/package/node_modules)");
  assert.doesNotThrow(function () {
    var tmpOutputDir = tmpDir();

    // rm -rf .npm/package/node_modules
    var nodeModulesDir = path.join(testPackageDir, ".npm", "package", "node_modules");
    assert(fs.existsSync(path.join(nodeModulesDir)));
    files.rm_recursive(nodeModulesDir);
    // We also have to delete the .build directory or else we won't rebuild at
    // all.
    // XXX this seems wrong!
    files.rm_recursive(path.join(testPackageDir, ".build.test-package"));
    assert(!fs.existsSync(path.join(nodeModulesDir)));
    reloadPackages();

    // while bundling, verify that we don't call `npm install
    // name@version unnecessarily` -- calling `npm install` is enough,
    // and installing each package separately could unintentionally bump
    // subdependency versions. (to intentionally bump subdependencies,
    // just remove all of the .npm directory)
    var bareExecFileSync = meteorNpm._execFileSync;
    meteorNpm._execFileSync = function (file, args, opts) {
      if (args.length > 1 && args[0] === 'install')
        assert.fail("shouldn't be installing specific npm packages: " + args[1]);
      return bareExecFileSync(file, args, opts);
    };
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    meteorNpm._execFileSync = bareExecFileSync;

    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);
    _assertCorrectPackageNpmDir({gcd: '0.0.0'});
    _assertCorrectBundleNpmContents(tmpOutputDir, {gcd: '0.0.0'});
  });

  console.log("app that uses gcd - add mime and semver");
  assert.doesNotThrow(function () {
    updateTestPackage({gcd: '0.0.0', mime: '1.2.7', semver: '1.1.0'});
    var tmpOutputDir = tmpDir();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);
    _assertCorrectPackageNpmDir({gcd: '0.0.0', mime: '1.2.7', semver: '1.1.0'});
    _assertCorrectBundleNpmContents(tmpOutputDir, {gcd: '0.0.0', mime: '1.2.7', semver: '1.1.0'});
  });

  console.log("app that uses gcd - add mime, as it would happen if you pulled in this change (updated npm-shrinkwrap.json but not node_modules)");
  assert.doesNotThrow(function () {
    var tmpOutputDir = tmpDir();

    // rm -rf .npm/package/node_modules/mime
    var nodeModulesMimeDir = path.join(testPackageDir, ".npm", "package", "node_modules", "mime");
    assert(fs.existsSync(path.join(nodeModulesMimeDir)));
    files.rm_recursive(nodeModulesMimeDir);
    // We also have to delete the .build directory or else we won't rebuild at
    // all.
    // XXX this seems wrong!
    files.rm_recursive(path.join(testPackageDir, ".build.test-package"));
    assert(!fs.existsSync(path.join(nodeModulesMimeDir)));

    reloadPackages();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);
    _assertCorrectPackageNpmDir({gcd: '0.0.0', mime: '1.2.7', semver: '1.1.0'});
    _assertCorrectBundleNpmContents(tmpOutputDir, {gcd: '0.0.0', mime: '1.2.7', semver: '1.1.0'});
  });

  console.log("app that uses gcd - upgrade mime, remove semver");
  assert.doesNotThrow(function () {
    updateTestPackage({gcd: '0.0.0', mime: '1.2.8'});
    var tmpOutputDir = tmpDir();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);
    _assertCorrectPackageNpmDir({gcd: '0.0.0', mime: '1.2.8'});
    _assertCorrectBundleNpmContents(tmpOutputDir, {gcd: '0.0.0', mime: '1.2.8'});
  });

  console.log("app that uses gcd - try downgrading mime to non-existant version");
  assert.doesNotThrow(function () {
    updateTestPackage({gcd: '0.0.0', mime: '0.1.2'});
    var tmpOutputDir = tmpDir();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert(result.errors);
    var job = _.find(result.errors.jobs, function (job) {
      return job.title === "building package `test-package`";
    });
    assert(job);
    assert(/mime version 0.1.2 is not available/.test(job.messages[0].message));
    _assertCorrectPackageNpmDir({gcd: '0.0.0', mime: '1.2.8'}); // shouldn't've changed
  });

  console.log("app that uses gcd - downgrade mime to an existant version");
  assert.doesNotThrow(function () {
    updateTestPackage({gcd: '0.0.0', mime: '1.2.7'});
    var tmpOutputDir = tmpDir();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);

    _assertCorrectPackageNpmDir({gcd: '0.0.0', mime: '1.2.7'});
    _assertCorrectBundleNpmContents(tmpOutputDir, {gcd: '0.0.0', mime: '1.2.7'});
  });

  console.log("app that uses gcd - install gzippo via tarball");
  assert.doesNotThrow(function () {
    var deps = {gzippo: 'https://github.com/meteor/gzippo/tarball/1e4b955439abc643879ae264b28a761521818f3b'};
    updateTestPackage(deps);
    var tmpOutputDir = tmpDir();
    var result = bundler.bundle({
      outputPath: tmpOutputDir
    });
    assert.strictEqual(result.errors, false, result.errors && result.errors[0]);
    _assertCorrectPackageNpmDir(deps);
    _assertCorrectBundleNpmContents(tmpOutputDir, deps);
    // Check that a string introduced by our fork is in the source.
    assert(/clientMaxAge = 604800000/.test(
      fs.readFileSync(
        path.join(testPackageDir, ".npm", "package", "node_modules", "gzippo", "lib", "staticGzip.js"), "utf8")));
  });

  console.log("bundle multiple apps in parallel using a meteor package dependent on an npm package");
  // this fails if we don't manage the package .npm directory correctly
  // against parallel bundling.  this happens if you are running more
  // than one app at once using a certain package and that package is
  // updated.
  // (Note that it still can fail if the _renameAlmostAtomically fails due to its
  //  lack of atomicity, but this is relatively rare.)
  assert.doesNotThrow(function () {
    updateTestPackage({gcd: '0.0.0', mime: '1.2.7'});
    // rm -rf .npm/package/node_modules, to make sure installing modules takes some time
    var nodeModulesDir = path.join(testPackageDir, ".npm", "package", "node_modules");
    assert(fs.existsSync(path.join(nodeModulesDir)));
    files.rm_recursive(nodeModulesDir);
    assert(!fs.existsSync(path.join(nodeModulesDir)));

    var futures = _.map(_.range(0, 10), function () {
      var future = new Future;
      Fiber(function () {
        var tmpAppDir = tmpDir();
        files.cp_r(appWithPackageDir, tmpAppDir);
        fs.writeFileSync(
          path.join(tmpAppDir, '.meteor', 'release'),
          release.current.isProperRelease()
            ? release.current.name : 'none');

        var tmpDirToPutBundleTarball = tmpDir();

        // bundle in a separate process, since we have various bits of
        // shared state, such as cached compiled packages
        try {
          var env = _.clone(process.env);
          env.PACKAGE_DIRS = tmpPackageDirContainer;

          // XXX: Calling this causes an SQLITE_BUSY error!
          var result = meteorNpm._execFileSync(
            process.env.METEOR_TOOL_PATH,
            ["bundle", path.join(tmpDirToPutBundleTarball, "bundle.tar.gz")],
            {cwd: tmpAppDir, env: env});

          files.rm_recursive(tmpDirToPutBundleTarball);
        } catch (e) {
          console.log(e.stdout);
          console.log(e.stderr);
          throw e;
        }
        assert(result.success, result.stderr);
        _assertCorrectPackageNpmDir({gcd: '0.0.0', mime: '1.2.7'});

        files.rm_recursive(tmpAppDir);
        future["return"]();
      }).run();
      return future;
    });

    Future.wait(futures);
  });

};


var Fiber = require('fibers');
Fiber(function () {
  setAppDir(appWithPackageDir);
  doOrThrow(function () {
    release._setCurrentForOldTest();
  });
  meteorNpm._printNpmCalls = true;

  try {
    runTest();
  } catch (err) {
    console.log(err.stack);
    console.log('\nBundle can be found at ' + lastTmpDir);
    process.exit(1);
  }
}).run();
